Repository navigation
Add the nvx-rs CLI - #596
Merged
Merged
Conversation
Pedro Henrique Penna (ppenna)
requested review from
Enrique Saurez (esaurez)
and
a balanced review from Copilot
October 17, 2026 04:43
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
Cross-platform lifecycle and guest-build changes need human review, and the Ctrl-C result-reporting issue remains unresolved.
1 open issue
What changed in this PR
Adds the nvx-rs CLI as groundwork for replacing Python runtime logic in #359. The stacked changes also migrate guest builds to Rust.
Changes:
- Adds artifact lookup, egress compilation, and sandbox commands with Python-compatible behavior.
- Adds process control, interrupt handling, and layered-sandbox feature requirements.
- Updates guest builds, provenance, tests, and documentation.
| File | Description |
|---|---|
| THIRD_PARTY_NOTICES.md | Adds Rust runtime and dependency notices. |
| SOURCE-MANIFEST.json | Records Rust guest inputs. |
| scripts/test_tool_versions.py | Checks musl target installation. |
| scripts/test_guest_binaries.py | Expands build and provenance tests. |
| scripts/setup/setup-linux-runner.sh | Explicitly selects target toolchain. |
| scripts/setup/setup-linux-mshv.sh | Installs and verifies musl target. |
| scripts/nvx.py | Adds guest source-identity command. |
| scripts/nvx_tools/ubuntu.py | Includes Rust inputs in provenance. |
| scripts/nvx_tools/release.py | Validates Rust source metadata. |
| scripts/nvx_tools/guest_binaries.py | Adds staged-build validation. |
| scripts/nvx_tools/ci.py | Tracks Rust guest test inputs. |
| scripts/nvx_tools/build.py | Installs shared Rust guest binaries. |
| scripts/nvx_tools/build_constants.py | Defines Rust build and packaging inputs. |
| scripts/nvx_tools/benchmark.py | Records Rust helper provenance. |
| scripts/nvx_tools/azurelinux.py | Includes Rust build inputs. |
| scripts/nvx_tools/archive.py | Excludes generated Rust targets. |
| README.md | Describes Rust guest binaries. |
| nvx-rs/src/protocol/tests.rs | Pins wire values and feature requirements. |
| nvx-rs/src/protocol/mod.rs | Updates shared-protocol documentation. |
| nvx-rs/src/protocol/features.rs | Defines layered-sandbox requirements. |
| nvx-rs/src/host/sys/mod.rs | Exposes Unix interrupt helpers. |
| nvx-rs/src/host/sys/interrupt.rs | Records Unix Ctrl-C signals. |
| nvx-rs/src/host/session.rs | Exposes request and response operations. |
| nvx-rs/src/host/sandbox/layer.rs | Implements sandbox layer parsing. |
| nvx-rs/src/host/python/value.rs | Implements Python value conversions. |
| nvx-rs/src/host/python/uuid.rs | Implements Python-compatible UUID parsing. |
| nvx-rs/src/host/python/text.rs | Preserves Python string semantics. |
| nvx-rs/src/host/python/shell.rs | Formats displayed command lines. |
| nvx-rs/src/host/python/mod.rs | Organizes Python compatibility helpers. |
| nvx-rs/src/host/python/dumps.rs | Produces Python-compatible JSON. |
| nvx-rs/src/host/process.rs | Adds child wait and termination helpers. |
| nvx-rs/src/host/platform/windows.rs | Adds waits, interrupts, and byte locks. |
| nvx-rs/src/host/platform/other.rs | Provides interrupt compatibility hooks. |
| nvx-rs/src/host/platform/linux.rs | Exposes Linux interrupt handling. |
| nvx-rs/src/host/mod.rs | Registers CLI and sandbox modules. |
| nvx-rs/src/host/cli/sandbox/identity.rs | Checks persisted process identities. |
| nvx-rs/src/host/cli/output.rs | Preserves Python stream formatting. |
| nvx-rs/src/host/cli/mod.rs | Dispatches commands and reports results. |
| nvx-rs/src/host/cli/egress.rs | Adds egress compilation command. |
| nvx-rs/src/host/cli/deadline.rs | Adds interruptible deadline helpers. |
| nvx-rs/src/host/cli/artifacts.rs | Discovers and prints artifact paths. |
| nvx-rs/src/guest/time/mod.rs | Updates historical C-port comments. |
| nvx-rs/src/guest/time_probe/mod.rs | Updates historical C-port comments. |
| nvx-rs/src/guest/reseed.rs | Clarifies comments without behavior changes. |
| nvx-rs/src/bin/nvx-rs.rs | Adds binary entry point. |
| nvx-rs/Cargo.toml | Declares CLI feature and dependencies. |
| guest/common/nvx-reseed.c | Removes replaced C implementation. |
| guest/common/nvx-mmio-write.c | Removes replaced C implementation. |
| guest/common/nvx-exit.c | Removes replaced C implementation. |
| guest/common/nvx-device-io.c | Removes replaced C implementation. |
| guest/common/nvx-console-pending.c | Removes replaced C implementation. |
| docker/Dockerfile | Shares Rust binaries across guest images. |
| doc/usage.md | Documents source-identity command. |
| doc/setup.md | Documents Rust guest prerequisites. |
| doc/project-structure.md | Describes CLI and Rust guest layout. |
| doc/guests/ubuntu-guest.md | Documents shared Rust binaries. |
| doc/guests/azurelinux-guest.md | Documents Rust binary provenance. |
| doc/contribute.md | Updates guest testing guidance. |
| doc/ci.md | Describes Rust build and cache inputs. |
| doc/build.md | Documents Rust builds and packaging. |
| doc/benchmarks.md | Updates helper provenance documentation. |
| aci_edge_sandboxes/src/openvmm/mod.rs | Updates egress documentation and tests. |
| aci_edge_sandboxes/README.md | Documents shared egress lowering changes. |
| .github/workflows/copilot-setup-steps.yml | Tracks all Rust crate changes. |
| .github/copilot-instructions.md | Documents musl setup prerequisites. |
| .github/actions/validate-nvx/action.yml | Removes retired C-driven test suites. |
| .github/actions/build-guest-artifacts/action.yml | Adds Rust-aware guest cache keys. |
| .gitattributes | Marks generated compatibility fixtures. |
Review effort: Balanced
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 17, 2026
A terminal's Ctrl-C reaches the whole foreground process group, so nvx-rs sandbox run and the OpenVMM that it runs on the terminal receive it together. run_openvmm polled OpenVMM before it checked for the interrupt, so when OpenVMM had already exited from the same Ctrl-C, the run returned OpenVMM's status instead of ending as interrupted: on Unix, a SIGINT-ended OpenVMM made nvx-rs exit with 254 and print nothing, where nvx.py prints "Interrupted" and exits with 130. Python's wait raises KeyboardInterrupt once SIGINT arrives, whether or not the child has exited meanwhile. run_openvmm now checks for the interrupt after each poll and before it reports a status, and only waits for, then kills, an OpenVMM that is still running. main() also lets a recorded Ctrl-C decide how any command ends, as KeyboardInterrupt does wherever it finds a Python command, so that a command that finished while Ctrl-C arrived still prints "Interrupted" and exits with 130. A new Linux test sends SIGINT to the CLI's whole process group, as a terminal does, during a one-shot run whose fake OpenVMM the signal ends at once. It failed with status 254 before this change, in three of three runs, and passes now; the test that interrupts the CLI alone still passes. Reported in the review of #596. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This was referenced Oct 17, 2026
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 17, 2026
nvx-rs sandbox identifies the OpenVMM of a running sandbox by its process ID and start time, which identity.rs reads on Linux from /proc/PID/stat. Ported from the former sandbox_lifecycle.py, it counted every zombie as exited. But /proc reports a process as a zombie as soon as its main thread exits, while its other threads may still run and hold its descriptors, so exec and stop could reject a live VM as stale, and deprovision could remove the state of a VM that still runs. The library's platform::process_start_time, with which the process helpers wait for and kill OpenVMM, already counts such a process as running until its last thread exits, as Windows does, and so does the CLI's own Windows branch; only its Linux branch disagreed. linux_start_time now asks, for a zombie only, whether other threads remain, through the platform's other_threads_remain, which reads /proc/PID/task, and otherwise keeps its Python-compatible parsing and messages. other_threads_remain becomes pub(crate) for that. The stat-line test covers a zombie with and without other threads, and an error while listing them. A new Linux test, like process::tests::a_process_runs_until_its_last_thread_exits, makes the main thread of a Python process exit through pthread_exit while another thread sleeps: process_running holds until the process is killed. It fails without the fix. Copilot's review of #598 flagged this in code that #596 added, the nvx-rs CLI; the platform helper is already on dev. The change stays within the CLI's Linux identity check. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
force-pushed
the
agents/fix-issue-359-cli
branch
from
October 19, 2026 13:29
e74e3c6 to
3117f77
Compare
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
A terminal's Ctrl-C reaches the whole foreground process group, so nvx-rs sandbox run and the OpenVMM that it runs on the terminal receive it together. run_openvmm polled OpenVMM before it checked for the interrupt, so when OpenVMM had already exited from the same Ctrl-C, the run returned OpenVMM's status instead of ending as interrupted: on Unix, a SIGINT-ended OpenVMM made nvx-rs exit with 254 and print nothing, where nvx.py prints "Interrupted" and exits with 130. Python's wait raises KeyboardInterrupt once SIGINT arrives, whether or not the child has exited meanwhile. run_openvmm now checks for the interrupt after each poll and before it reports a status, and only waits for, then kills, an OpenVMM that is still running. main() also lets a recorded Ctrl-C decide how any command ends, as KeyboardInterrupt does wherever it finds a Python command, so that a command that finished while Ctrl-C arrived still prints "Interrupted" and exits with 130. A new Linux test sends SIGINT to the CLI's whole process group, as a terminal does, during a one-shot run whose fake OpenVMM the signal ends at once. It failed with status 254 before this change, in three of three runs, and passes now; the test that interrupts the CLI alone still passes. Reported in the review of #596. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
force-pushed
the
agents/fix-issue-359-cli
branch
from
October 19, 2026 14:43
3117f77 to
6ae3c45
Compare
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
A terminal's Ctrl-C reaches the whole foreground process group, so nvx-rs sandbox run and the OpenVMM that it runs on the terminal receive it together. run_openvmm polled OpenVMM before it checked for the interrupt, so when OpenVMM had already exited from the same Ctrl-C, the run returned OpenVMM's status instead of ending as interrupted: on Unix, a SIGINT-ended OpenVMM made nvx-rs exit with 254 and print nothing, where nvx.py prints "Interrupted" and exits with 130. Python's wait raises KeyboardInterrupt once SIGINT arrives, whether or not the child has exited meanwhile. run_openvmm now checks for the interrupt after each poll and before it reports a status, and only waits for, then kills, an OpenVMM that is still running. main() also lets a recorded Ctrl-C decide how any command ends, as KeyboardInterrupt does wherever it finds a Python command, so that a command that finished while Ctrl-C arrived still prints "Interrupted" and exits with 130. A new Linux test sends SIGINT to the CLI's whole process group, as a terminal does, during a one-shot run whose fake OpenVMM the signal ends at once. It failed with status 254 before this change, in three of three runs, and passes now; the test that interrupts the CLI alone still passes. Reported in the review of #596. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the cli feature, which implies host, and the nvx-rs binary, with required-features = ["cli"]: the command-line interface to which nvx.py will delegate its sandbox, egress-policy, and artifact steps (#359). This commit adds the command-line framework and three commands; the managed lifecycle of nvx-rs sandbox follows in the next commit, and until then provision, start, exec, stop, and deprovision fail after their option checks. - nvx-rs [--root PATH] artifacts prints ArtifactDir::paths as JSON, as json.dumps(value, indent=2, sort_keys=True) writes it. The root is --root, which nvx.py will pass, or else the release whose bin/ holds the executable, or the checkout whose nvx-rs/target/ holds it, either with its SOURCE-MANIFEST.json. The NVX_OPENVMM variable names an OpenVMM executable to run instead of the root's, such as a test double; a relative path is made absolute. - nvx-rs egress compile PATH prints the rules of compile_policy_file as {"allow": [...], "deny": [...]}, each list in the order in which OpenVMM receives them, and fails with the compiler's message. - nvx-rs sandbox ports the sandbox parser and command_sandbox of scripts/nvx.py: every option with its name, default, type, and choices, and the checks of SandboxOptions::check, the egress policy file, the mounts, the launch, and the systemd policy, in Python's order and with its messages. A one-shot run prints ">> COMMAND", quoted as subprocess.list2cmdline quotes it on Windows and as shlex.join does elsewhere, and unless --dry-run runs OpenVMM with the standard streams and exits with its status. The artifact checks keep the messages and order of nvx.py, and --hypervisor auto resolves to whp on Windows and to kvm elsewhere. The parser reproduces argparse step by step: --opt value and --opt=value; values that start with a dash only after "=", unless they look like negative numbers, in the decimal digits of any script, which argparse's \d matches, or hold a space; the alternation of options and positionals; the optional operation positional, which defaults to run; repeated options that accumulate; and the messages and status 2 of usage errors, with unrecognized arguments reported by the top-level parser after the subcommand parsed. It does not expand abbreviations, since nvx.py will pass canonical options; that is an intended deviation. Help and usage are argparse's for 80 columns. Errors print "error: MESSAGE" and exit with status 1, as main() of nvx.py does for a ScriptError. Ctrl-C prints "Interrupted" and exits with status 130. A SIGINT handler on Unix, installed only if SIGINT is not ignored, and a console control handler on Windows record it, and a run then gives OpenVMM 0.25 s to exit before it kills it on POSIX, as subprocess.run does, and waits for it on Windows, where Python cannot interrupt the wait either. Ctrl-C also ends a write to standard output, help included, as KeyboardInterrupt ends Python's, even one that waits for a pipe's reader that does not read: since the handler restarts the system calls that it interrupts, the CLI waits for room with poll in 250 ms slices and writes at most PIPE_BUF bytes at once on Unix; on Windows, where Python cannot end such a write either, it runs to its end. Text ends its lines as Python's print ends them, with \r\n on Windows. The private python module of #594 and #595 gains dumps, json.dumps with indent=2, sort_keys=True, and ensure_ascii; shell, subprocess.list2cmdline, shlex.quote, and shlex.join; and float() in builtins for positive_float. The sys and platform modules gain the interrupt primitives, and windows-sys its console bindings. Unit tests compare the parser with argparse, the Python helpers with CPython, and cover the discovery of the artifact root. Golden scenarios recorded from nvx.py follow in a later commit. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/nvx_tools/sandbox_lifecycle.py, and the ControlSession of
control_session.py that it uses, so that nvx-rs sandbox provision,
start, exec, stop, and deprovision keep the contract of nvx.py
sandbox: the same state directory, files, formats, locks, messages,
outputs, and exit statuses. Either implementation can operate a
sandbox that the other provisioned or started.
State, in sandbox/state.rs:
- config.json in the lowest of formats 1 to 6 that holds the sandbox,
and runtime.json, format 1, with the pid, start_time, and
control_endpoint of OpenVMM. Both are written as json.dumps(value,
indent=2, sort_keys=True) + "\n" writes them, ASCII-escaped and with
\r\n line ends on Windows as write_text produces them, through a
temporary file, chmod 0600, and a rename.
- Reading applies Python's semantics to whatever another writer left:
strict UTF-8, == comparisons of the format, str() and int() of the
fields, and the order and messages of _deserialize_launch.
Locks, in sandbox/lock.rs: lifecycle.lock is locked as Python locks
it, so the implementations exclude each other: flock(LOCK_EX |
LOCK_NB) on POSIX, and on Windows CreateFileW with read, write, and
delete sharing and FILE_FLAG_OPEN_REPARSE_POINT, then LockFile of the
first byte, as msvcrt.locking(LK_NBLCK, 1) takes it. The
standalone-regular-file checks, the retries every 25 ms within
--timeout, the hand-off waits, the "deprovisioned" tombstone, and the
removal of the directory are ported, with Windows errors classed as
CPython maps them to errno.
Process identity, in sandbox/identity.rs, is Python's: on Linux the
start time in /proc/PID/stat, with zombies as exited, and on Windows
the creation time, so that a runtime record that either implementation
wrote is valid for the other.
The lifecycle, in sandbox/lifecycle.rs:
- start generates a 32-byte capability, never all zero, writes
control.capability, passes the capability on OpenVMM's standard
input through a pipe closed before OpenVMM starts, appends its
output to openvmm.log, and starts it detached, in its own session
on POSIX and process group on Windows, with _start's command line:
the managed lifecycle, its network options in _start's order, and
the endpoint STATE/control.sock, or //./pipe/openvmm-microvm-HEX
with a uuid4 on Windows. It records runtime.json, then connects,
attaches, and pings. A failed start ends OpenVMM and removes the
runtime files in _start's order, and reports OpenVMM's exit as
_startup_exit_message does.
- exec buffers the workload's output, writes standard output, then
standard error, and exits with the workload's status. Ctrl-C ends
either write, as it ends other writes to standard output, while the
report of how a command ended, "Interrupted" among them, still
reaches standard error. With --outcome-report, it checks the
destination before it runs and writes {"operation_id", "outcome",
"schema_version": 1} as _write_new_json does.
- stop asks the guest to shut down, waits for OpenVMM, reads its
outcome report, removes the runtime files, and prints nothing, as
nvx.py sandbox stop does. deprovision removes the state directory.
The session, in session.rs, keeps the observable behavior of
control_session.py on nvx::host::session, whose ControlSession gains
send_request and read_response for callers that check the responses
themselves: connection retries every 25 ms until the timeout, then
"managed control endpoint did not become available: PATH"; one
deadline per operation for its request and its responses; "managed
control request timed out" or "managed control response timed out"
by the direction of the transfer; ControlEndpointClosed for a closed
endpoint, but not for a reset; a session closed after a failed
transfer; and exec's checks, in their order and with their messages,
its exit categories, and its refusals with their status and ASCII
category. Waits run in 250 ms slices, so that Ctrl-C ends them.
Intended deviations: an endpoint that a process other than the
launched OpenVMM serves counts as one that has not appeared yet, as
in nvx::host::session; OpenVMM starts without a console window on
Windows, as nvx::host::process starts it; exec refuses more than the
1 MiB of output that the guest agent forwards; and where Python would
end with a traceback, as for a configuration without a field that
_start reads, nvx-rs prints "error: MESSAGE" with the exception's
message and exits with status 1.
The base modules gain what the port needs: process::Launched waits
for, terminates, and kills OpenVMM as Popen does; the Windows platform
module locks the first byte of a file and reads exit codes; the
transports of nvx::host::platform bound a whole write by its timeout,
as the streams of control_session.py do, so that an endpoint that
drains a request slowly cannot hold an operation past its timeout: the
Unix transport sends without waiting and polls for room within what is
left, since a blocking send restarts SO_SNDTIMEO whenever the endpoint
makes room, and the named-pipe transport takes the wait of each write
from the same deadline; and the python module adds value, str(),
int(), and == of JSON values, with floats in the shortest digits of
repr, which of two equally close takes the one that ends in an even
digit, and lstat, lexists, and fstat with st_file_attributes on
Windows.
Unit tests cover the formats, the JSON writer, identity parsing, the
locks, and the lifecycle helpers. The tests of control_session.py and
golden scenarios recorded from nvx.py follow.
Part of #359.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/test_control_session.py to the session of nvx-rs sandbox, in host/cli/session/tests.rs, except the cases that the tests of nvx::host::session, whose framing the session reuses, already cover. Session::connect now attaches through Session::attach, which the tests call on their own transports. The ControlSessionTests run on an in-memory stream pair, like the socketpair of the Python tests, with a guest thread that plays OpenVMM's endpoint: exec streams output and returns its status; refuses relative entrypoints, more than 64 arguments, and invalid arguments, timeouts, working directories, environments, and response timeouts before it sends anything; encodes the legacy and extended requests, with their working directory and omitted, empty, replaced, or layered environments, exactly; accepts the whole u32 timeout range; refuses unknown exit categories, including "cancelled", which the CLI never requests; and keeps the status and category of a refusal. A reset is not a closed endpoint, every transfer of an operation gets no more than what is left of its timeout, and a failed transfer closes the session. The stream tests run on the endpoints of each host, which this process serves: on POSIX, Unix-domain sockets whose closure and partial reads report a closed endpoint; on Windows, named pipes whose closure reports a closed endpoint after the data that they held, and whose read timeout closes the session. A stalled endpoint, which reads nothing, times out the request within its timeout on both hosts, as does the transport of an endpoint that keeps reading slowly, which a write far larger than its buffers makes wait, and an endpoint that never appears times out with its path. The Windows tests create their pipe servers through windows-sys, so their module allows unsafe code. Not ported: the capability-pipe tests, since the pipe is nvx::host::process::spawn's, which writes the capability and closes the pipe before OpenVMM starts, and which every start of the golden lifecycle scenarios exercises, since the fake OpenVMM requires exactly the capability and the end of the file on its standard input; the mocked transfers that move nothing, which the closed-endpoint tests cover; reads that time out without losing data, which nvx::host::session's timed_out_polls_keep_partial_records covers; and the cancellation of an interrupted pipe write, since the CLI checks Ctrl-C between waits and bounds each write by its timeout. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
As the #359 discussion decided, nvx-rs sandbox start now negotiates the guest's control features, with a required set of its own rather than GuestFeatures::REQUIRED, which describes the direct agent of nvx::host::instance. This is the one intended change from nvx.py sandbox start, which does not ask. GuestFeatures::LAYERED_SANDBOX holds exactly what the CLI's launches use: EXEC_ENVIRONMENT (bit 4), because exec sends a working directory and an environment in an extended EXEC. It holds no CANCEL (bit 0): the CLI never sends APP_CANCEL, since, as nvx.py did, Ctrl-C ends an exec by closing the session, and the guest agent ends the workload when OpenVMM resets the session that it ran for. It holds no WORKLOAD_ACCOUNT (bit 2), which no sandbox launch requests and the layered root does not perform (#566). The NVX guest agents, the Rust agent of #561 and the C agent before it, advertise bits 0, 2, and 4 in sandbox mode. After its PING, start sends FEATURES. An agent that predates the request refuses it as an unsupported operation and so provides none. If the guest lacks a required feature, start ends OpenVMM and removes the runtime files, as a failed start does, and fails with error: the guest image lacks control features that layered sandboxes need (per-execution environments); use an NVX release or build that includes them which names the missing features with GuestFeatures::missing, as the reject_guest of nvx::host::instance does. Tests cover the required set, the features responses of current, older, and misbehaving agents, and, with the golden scenarios that follow, a start against guests that lack the feature or predate the request. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend nvx::testing::fake_openvmm, and so nvx-fake-openvmm, to the sandbox profile that nvx.py sandbox and nvx-rs sandbox launch, so that both can run end to end without a VM. A --microvm-sandbox-block option selects the profile; direct launches, which nvx::host::instance and aci_edge_sandboxes use, are unchanged. - The fake accepts the sandbox's arguments and checks them as OpenVMM would: read-only distro, runtime, and custom layers and one scratch disk, each an existing file; nvx_sandbox=1, one nvx_entrypoint=, and one nvx_hostname= on the kernel command line; one --mount of a directory, or the --mount-child children of an aggregate at /run/nvx/shares; --mount-owner and --network-proxy; and the network options, which it summarizes as OpenVMM's report does. - --microvm-lifecycle managed serves the control console as before, with the capability on standard input, and its guest advertises GuestFeatures::SANDBOX_AGENT. The guest targets of directory shares, from --mount and the nvx_share= tokens of an aggregate, are directories that a workload can enter. - --microvm-lifecycle one-shot reads no capability and serves no console: it runs the entrypoint and arguments of the kernel command line on its own standard streams, /bin/echo, /bin/sleep, /bin/true, or /bin/false, with any other program not found and status 127, writes an outcome report of operation "run" if asked, and exits with the workload's status, or with fake_exit_status=STATUS. - The outcome reports of sandboxes take the form of OpenVMM's: the schema version, a random instance ID, the backend, the outcome, the network policy, and every teardown step, as JSON with a final newline. The existing knobs keep working for sandboxes, among them fake_exit_on_start, fake_guest_features, and fake_legacy_guest, which the golden scenarios and the guest-feature tests use. The fake OpenVMM tests still pass unchanged. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record what nvx.py sandbox does, as a command, and replay it with nvx-rs in tests that need no Python, so that the CLI stays faithful to nvx.py after PR D deletes the Python code. scripts/nvx_rs_fixtures.py, the generator of #595, now also writes nvx-rs/tests/fixtures/cli.json, which its single --check covers. It builds nvx-rs and nvx-fake-openvmm with cargo and plays scenarios in fresh temporary roots, each with fake guest artifacts and a copy of nvx-fake-openvmm as the root's OpenVMM executable: - 24 usage errors and helps of the sandbox parser, 69 option and launch checks of command_sandbox, 11 dry runs, 11 one-shot runs, the lifecycles of sandboxes in every configuration format, and 8 scenarios of the refusals and failures of the lifecycle operations, 215 steps in all on POSIX and 209 on Windows, where a caller-owned share, and so format 3, is refused; - nvx-rs egress compile and nvx-rs artifacts, from the Python functions that they port, compile_policy_file and the paths of scripts/nvx_tools/common.py and guests.py. Each step records its exit status, standard output and error, and the files that it leaves, such as config.json in formats 1 to 6, runtime.json, the state directory's entries and modes, outcome reports, and the command line that start passed to OpenVMM. The generator runs nvx.py in a subprocess, with artifact_path and openvmm_binary_path patched, unbuffered and with UTF-8 standard streams, no terminal size, and no colors. It replaces the root, as printed and as JSON escapes it, process IDs, start times, and random identifiers with placeholders, and the program names of nvx.py with those of nvx-rs, whose parser names have the same length, so usage wraps alike. The posix and windows sections hold the scenarios of each kind of host, recorded there; the script rewrites or checks the section of the host that runs it and keeps the other. Recording needs CPython 3.14.5 or later, whose argparse quotes the choices of an invalid choice, as nvx-rs does. The script then plays every scenario with nvx-rs, and each lifecycle with nvx.py and nvx-rs taking turns both ways, so that each provisions, starts, executes in, stops, and deprovisions a sandbox of the other, and fails if any step differs from nvx.py. Both sections pass on Windows and Linux. nvx-rs/tests/cli.rs replays the section of its host with nvx-rs, four scenarios at a time, in canonical temporary roots. It also checks what nvx-rs adds: a start against guests that lack per-execution environments or predate the features request, the default root of the executable in a checkout, a relative NVX_OPENVMM, the top-level usage errors, and on Linux, Ctrl-C during a run, which ends OpenVMM, during an exec, which leaves the sandbox running, and while an exec writes its output, or help is written, to a full pipe that nothing reads, which Ctrl-C ends too. .gitattributes marks the fixture, about 370 KB, as generated. This changes the generator of #595 only by adding the CLI section. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe in nvx-rs/README.md the nvx-rs CLI that the cli feature builds, which PR D's nvx.py will run: - its commands and how it finds the artifact root: --root, which nvx.py will pass, or else the release or checkout that holds the executable, and the NVX_OPENVMM override; - the JSON forms of artifacts and egress compile, and the contract of sandbox: its options, outputs, exit statuses, outcome reports, and the state directory that it shares with nvx.py; - the guest features that start requires, and why bits 0 and 2 are not among them; - the other intended differences from nvx.py; - the test hooks, --root, NVX_OPENVMM, and the fake OpenVMM's sandbox profile, and the golden scenarios with their interoperability checks. The layout, module, and features tables, the Python parity and testing sections, and the introduction mention the CLI, and doc/project-structure.md lists it in the nvx-rs tree and summary. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A terminal's Ctrl-C reaches the whole foreground process group, so nvx-rs sandbox run and the OpenVMM that it runs on the terminal receive it together. run_openvmm polled OpenVMM before it checked for the interrupt, so when OpenVMM had already exited from the same Ctrl-C, the run returned OpenVMM's status instead of ending as interrupted: on Unix, a SIGINT-ended OpenVMM made nvx-rs exit with 254 and print nothing, where nvx.py prints "Interrupted" and exits with 130. Python's wait raises KeyboardInterrupt once SIGINT arrives, whether or not the child has exited meanwhile. run_openvmm now checks for the interrupt after each poll and before it reports a status, and only waits for, then kills, an OpenVMM that is still running. main() also lets a recorded Ctrl-C decide how any command ends, as KeyboardInterrupt does wherever it finds a Python command, so that a command that finished while Ctrl-C arrived still prints "Interrupted" and exits with 130. A new Linux test sends SIGINT to the CLI's whole process group, as a terminal does, during a one-shot run whose fake OpenVMM the signal ends at once. It failed with status 254 before this change, in three of three runs, and passes now; the test that interrupts the CLI alone still passes. Reported in the review of #596. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
crashed_vms_fall_back_to_provisioned polled with execs until one failed, while the crash timer of the fake OpenVMM could fire at any time. When the crash came during an exec's control session, after the backend had found the VM running but before the exec request was sent, the session read the end of the stream, and the exec failed with BackendError, "the control endpoint closed the session", as the error mapping documents for a control-session failure, instead of NotStarted. The aci-edge-sandboxes job failed so on Windows in run 39260540295, and the test failed in 1 of 55 local Windows runs. The test now waits until platform::process_start_time no longer reports the recorded OpenVMM process, the check with which the backend reconciles a sandbox, and then expects an exec to fail with NotStarted, a stop with AlreadyStopped, and a deprovision to succeed. It passed 40 of 40 runs on Windows and 25 of 25 on Linux. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
force-pushed
the
agents/fix-issue-359-cli
branch
from
October 19, 2026 15:02
6ae3c45 to
58f7da0
Compare
This was referenced Oct 19, 2026
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox will run nvx-rs sandbox with the options that its own argparse front end read, and its type conversions, such as --layer, --workload-user, and the integer options, move to nvx-rs. A value that nvx-rs then refuses would read "usage: nvx-rs sandbox ..." and "nvx-rs sandbox: error: ...", where nvx.py printed the usage and name of its own parser. A new global option, --prog PROG, names the program in usage, help, and error messages, nvx-rs by default; nvx.py will pass its own name. Each parser now describes its syntax as a Syntax value, its command after the program's name, the usage parts of its options and positionals, and the help after its usage, instead of static usage and help text, and Exit::Usage and Exit::Help carry the text that the syntax formats for the program. format_usage ports argparse's HelpFormatter._format_usage for 80 columns: a usage that fits stays on one line; otherwise a name of at most three quarters of the width is followed by the options, and the positionals start a new line, while a longer name takes a line of its own. For the default name, every usage and help is byte for byte what the static text printed, which the golden scenarios check, and for nvx.py, whose name has the length of nvx-rs, the usage wraps as Python's does. The top-level usage lists the new option, so the eight golden steps whose unrecognized arguments the top-level parser reports, and the generator's copy of that usage, change accordingly. The top-level help describes --prog and wraps the help of --root as argparse wraps it; it had one line of 79 columns. Unit tests compare the wrapping of short and long names, and of usages without options, with CPython 3.14's argparse, and a CLI test checks the usage error of nvx.py sandbox for a root --workload-user, recorded from CPython, a long name, the top-level parser, and help. The README documents the option. This changes the CLI of #596; it adds the option and keeps every default output. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox now runs `nvx-rs --prog nvx.py --root REPO sandbox` for every operation, with nvx.py's standard streams, and exits with the status of nvx-rs, which ports command_sandbox, the lifecycle of sandbox_lifecycle.py, and the control session of control_session.py (#596). Python no longer checks, converts, or runs anything for the command; the modules that nvx-rs replaced stay for the microVM tests until a later commit moves those onto nvx-rs. argparse stays the front end, so that the help, abbreviations, and the usage errors that it reports (exit 2) work as before, and the help is unchanged byte for byte at 80 and 120 columns. Every option keeps its name, metavar, choices, and help, but none has a type converter or a default: --layer, --workload-user, the integer options, --timeout, and the paths are plain strings that nvx-rs converts and checks, and nvx-rs applies the defaults, so Python duplicates neither. A _SandboxOption action records each option as nvx-rs receives it: - under its canonical name, so that `--dry` reaches nvx-rs as --dry-run, since nvx-rs does not expand abbreviations; - in command-line order, so that each --mount-deny, --mount-allow, and --mount-write follows the --mount whose path it names, which nvx-rs attributes as nvx.py did; and - with its value attached, --option=VALUE, so that a value that starts with "-" stays a value. The operation, run by default, comes first. Only options that the user gave are passed, so that the defaults live in nvx-rs alone. --prog makes the usage errors that nvx-rs reports for the values that it converts read "usage: nvx.py sandbox ..." and "nvx.py sandbox: error: ...", as argparse printed them; the name has the length of nvx-rs, so the usage wraps the same. Usage errors keep their status and form, but which of two errors in one command line is reported changes: argparse reports an invalid choice or an unrecognized argument before nvx-rs sees a value that it refuses, where it used to report whichever came first. Ctrl-C ends the command with nvx-rs's Interrupted and status 130, once. scripts/nvx_rs_fixtures.py recorded the golden fixtures of nvx-rs from the Python implementation of nvx.py sandbox, the egress compiler, and the Python semantics that the ports keep, and imported the helpers that this commit removes from nvx.py. With nvx.py running nvx-rs, the recordings are final, so the generator goes, and the fixtures stay as frozen references that a deliberate change updates by hand. Their metadata, .gitattributes, the README, the module comments that named the generator, and doc/project-structure.md say so. The CLI tests of the Python implementation go, since the golden scenarios of nvx-rs cover each of their cases, as do the systemd, environment, network-option, and policy checks, and the tests of `run` that also ran sandbox keep their `run` half. New tests check the recorded arguments for abbreviations, attached values, order, and the default operation, the call of nvx-rs and its status through main(), the usage errors that argparse keeps, a missing nvx-rs, and, through a fresh nvx.py with a stand-in for nvx-rs, shared standard streams and, on POSIX hosts, one Interrupted and status 130 after Ctrl-C to the process group. With NVX_RS set, the real nvx-rs runs a dry run of options that need an attached value, an abbreviation, and a share policy, a usage error with nvx.py's name, and a lifecycle error. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox will run nvx-rs sandbox with the options that its own argparse front end read, and its type conversions, such as --layer, --workload-user, and the integer options, move to nvx-rs. A value that nvx-rs then refuses would read "usage: nvx-rs sandbox ..." and "nvx-rs sandbox: error: ...", where nvx.py printed the usage and name of its own parser. A new global option, --prog PROG, names the program in usage, help, and error messages, nvx-rs by default; nvx.py will pass its own name. Each parser now describes its syntax as a Syntax value, its command after the program's name, the usage parts of its options and positionals, and the help after its usage, instead of static usage and help text, and Exit::Usage and Exit::Help carry the text that the syntax formats for the program. format_usage ports argparse's HelpFormatter._format_usage for 80 columns: a usage that fits stays on one line; otherwise a name of at most three quarters of the width is followed by the options, and the positionals start a new line, while a longer name takes a line of its own. For the default name, every usage and help is byte for byte what the static text printed, which the golden scenarios check, and for nvx.py, whose name has the length of nvx-rs, the usage wraps as Python's does. The top-level usage lists the new option, so the eight golden steps whose unrecognized arguments the top-level parser reports, and the generator's copy of that usage, change accordingly. The top-level help describes --prog and wraps the help of --root as argparse wraps it; it had one line of 79 columns. Unit tests compare the wrapping of short and long names, and of usages without options, with CPython 3.14's argparse, and a CLI test checks the usage error of nvx.py sandbox for a root --workload-user, recorded from CPython, a long name, the top-level parser, and help. The README documents the option. This changes the CLI of #596; it adds the option and keeps every default output. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox now runs `nvx-rs --prog nvx.py --root REPO sandbox` for every operation, with nvx.py's standard streams, and exits with the status of nvx-rs, which ports command_sandbox, the lifecycle of sandbox_lifecycle.py, and the control session of control_session.py (#596). Python no longer checks, converts, or runs anything for the command; the modules that nvx-rs replaced stay for the microVM tests until a later commit moves those onto nvx-rs. argparse stays the front end, so that the help, abbreviations, and the usage errors that it reports (exit 2) work as before, and the help is unchanged byte for byte at 80 and 120 columns. Every option keeps its name, metavar, choices, and help, but none has a type converter or a default: --layer, --workload-user, the integer options, --timeout, and the paths are plain strings that nvx-rs converts and checks, and nvx-rs applies the defaults, so Python duplicates neither. A _SandboxOption action records each option as nvx-rs receives it: - under its canonical name, so that `--dry` reaches nvx-rs as --dry-run, since nvx-rs does not expand abbreviations; - in command-line order, so that each --mount-deny, --mount-allow, and --mount-write follows the --mount whose path it names, which nvx-rs attributes as nvx.py did; and - with its value attached, --option=VALUE, so that a value that starts with "-" stays a value. The operation, run by default, comes first. Only options that the user gave are passed, so that the defaults live in nvx-rs alone. --prog makes the usage errors that nvx-rs reports for the values that it converts read "usage: nvx.py sandbox ..." and "nvx.py sandbox: error: ...", as argparse printed them; the name has the length of nvx-rs, so the usage wraps the same. Usage errors keep their status and form, but which of two errors in one command line is reported changes: argparse reports an invalid choice or an unrecognized argument before nvx-rs sees a value that it refuses, where it used to report whichever came first. Ctrl-C ends the command with nvx-rs's Interrupted and status 130, once. scripts/nvx_rs_fixtures.py recorded the golden fixtures of nvx-rs from the Python implementation of nvx.py sandbox, the egress compiler, and the Python semantics that the ports keep, and imported the helpers that this commit removes from nvx.py. With nvx.py running nvx-rs, the recordings are final, so the generator goes, and the fixtures stay as frozen references that a deliberate change updates by hand. Their metadata, .gitattributes, the README, the module comments that named the generator, and doc/project-structure.md say so. The CLI tests of the Python implementation go, since the golden scenarios of nvx-rs cover each of their cases, as do the systemd, environment, network-option, and policy checks, and the tests of `run` that also ran sandbox keep their `run` half. New tests check the recorded arguments for abbreviations, attached values, order, and the default operation, the call of nvx-rs and its status through main(), the usage errors that argparse keeps, a missing nvx-rs, and, through a fresh nvx.py with a stand-in for nvx-rs, shared standard streams and, on POSIX hosts, one Interrupted and status 130 after Ctrl-C to the process group. With NVX_RS set, the real nvx-rs runs a dry run of options that need an attached value, an abbreviation, and a share policy, a usage error with nvx.py's name, and a lifecycle error. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox will run nvx-rs sandbox with the options that its own argparse front end read, and its type conversions, such as --layer, --workload-user, and the integer options, move to nvx-rs. A value that nvx-rs then refuses would read "usage: nvx-rs sandbox ..." and "nvx-rs sandbox: error: ...", where nvx.py printed the usage and name of its own parser. A new global option, --prog PROG, names the program in usage, help, and error messages, nvx-rs by default; nvx.py will pass its own name. Each parser now describes its syntax as a Syntax value, its command after the program's name, the usage parts of its options and positionals, and the help after its usage, instead of static usage and help text, and Exit::Usage and Exit::Help carry the text that the syntax formats for the program. format_usage ports argparse's HelpFormatter._format_usage for 80 columns: a usage that fits stays on one line; otherwise a name of at most three quarters of the width is followed by the options, and the positionals start a new line, while a longer name takes a line of its own. For the default name, every usage and help is byte for byte what the static text printed, which the golden scenarios check, and for nvx.py, whose name has the length of nvx-rs, the usage wraps as Python's does. The top-level usage lists the new option, so the eight golden steps whose unrecognized arguments the top-level parser reports, and the generator's copy of that usage, change accordingly. The top-level help describes --prog and wraps the help of --root as argparse wraps it; it had one line of 79 columns. Unit tests compare the wrapping of short and long names, and of usages without options, with CPython 3.14's argparse, and a CLI test checks the usage error of nvx.py sandbox for a root --workload-user, recorded from CPython, a long name, the top-level parser, and help. The README documents the option. This changes the CLI of #596; it adds the option and keeps every default output. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox now runs `nvx-rs --prog nvx.py --root REPO sandbox` for every operation, with nvx.py's standard streams, and exits with the status of nvx-rs, which ports command_sandbox, the lifecycle of sandbox_lifecycle.py, and the control session of control_session.py (#596). Python no longer checks, converts, or runs anything for the command; the modules that nvx-rs replaced stay for the microVM tests until a later commit moves those onto nvx-rs. argparse stays the front end, so that the help, abbreviations, and the usage errors that it reports (exit 2) work as before, and the help is unchanged byte for byte at 80 and 120 columns. Every option keeps its name, metavar, choices, and help, but none has a type converter or a default: --layer, --workload-user, the integer options, --timeout, and the paths are plain strings that nvx-rs converts and checks, and nvx-rs applies the defaults, so Python duplicates neither. A _SandboxOption action records each option as nvx-rs receives it: - under its canonical name, so that `--dry` reaches nvx-rs as --dry-run, since nvx-rs does not expand abbreviations; - in command-line order, so that each --mount-deny, --mount-allow, and --mount-write follows the --mount whose path it names, which nvx-rs attributes as nvx.py did; and - with its value attached, --option=VALUE, so that a value that starts with "-" stays a value. The operation, run by default, comes first. Only options that the user gave are passed, so that the defaults live in nvx-rs alone. --prog makes the usage errors that nvx-rs reports for the values that it converts read "usage: nvx.py sandbox ..." and "nvx.py sandbox: error: ...", as argparse printed them; the name has the length of nvx-rs, so the usage wraps the same. Usage errors keep their status and form, but which of two errors in one command line is reported changes: argparse reports an invalid choice or an unrecognized argument before nvx-rs sees a value that it refuses, where it used to report whichever came first. Ctrl-C ends the command with nvx-rs's Interrupted and status 130, once. scripts/nvx_rs_fixtures.py recorded the golden fixtures of nvx-rs from the Python implementation of nvx.py sandbox, the egress compiler, and the Python semantics that the ports keep, and imported the helpers that this commit removes from nvx.py. With nvx.py running nvx-rs, the recordings are final, so the generator goes, and the fixtures stay as frozen references that a deliberate change updates by hand. Their metadata, .gitattributes, the README, the module comments that named the generator, and doc/project-structure.md say so. The CLI tests of the Python implementation go, since the golden scenarios of nvx-rs cover each of their cases, as do the systemd, environment, network-option, and policy checks, and the tests of `run` that also ran sandbox keep their `run` half. New tests check the recorded arguments for abbreviations, attached values, order, and the default operation, the call of nvx-rs and its status through main(), the usage errors that argparse keeps, a missing nvx-rs, and, through a fresh nvx.py with a stand-in for nvx-rs, shared standard streams and, on POSIX hosts, one Interrupted and status 130 after Ctrl-C to the process group. With NVX_RS set, the real nvx-rs runs a dry run of options that need an attached value, an abbreviation, and a share policy, a usage error with nvx.py's name, and a lifecycle error. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox now runs `nvx-rs --prog nvx.py --root REPO sandbox` for every operation, with nvx.py's standard streams, and exits with the status of nvx-rs, which ports command_sandbox, the lifecycle of sandbox_lifecycle.py, and the control session of control_session.py (#596). Python no longer checks, converts, or runs anything for the command; the modules that nvx-rs replaced stay for the microVM tests until a later commit moves those onto nvx-rs. argparse stays the front end, so that the help, abbreviations, and the usage errors that it reports (exit 2) work as before, and the help is unchanged byte for byte at 80 and 120 columns. Every option keeps its name, metavar, choices, and help, but none has a type converter or a default: --layer, --workload-user, the integer options, --timeout, and the paths are plain strings that nvx-rs converts and checks, and nvx-rs applies the defaults, so Python duplicates neither. A _SandboxOption action records each option as nvx-rs receives it: - under its canonical name, so that `--dry` reaches nvx-rs as --dry-run, since nvx-rs does not expand abbreviations; - in command-line order, so that each --mount-deny, --mount-allow, and --mount-write follows the --mount whose path it names, which nvx-rs attributes as nvx.py did; and - with its value attached, --option=VALUE, so that a value that starts with "-" stays a value. The operation, run by default, comes first. Only options that the user gave are passed, so that the defaults live in nvx-rs alone. --prog makes the usage errors that nvx-rs reports for the values that it converts read "usage: nvx.py sandbox ..." and "nvx.py sandbox: error: ...", as argparse printed them; the name has the length of nvx-rs, so the usage wraps the same. Usage errors keep their status and form, but which of two errors in one command line is reported changes: argparse reports an invalid choice or an unrecognized argument before nvx-rs sees a value that it refuses, where it used to report whichever came first. Ctrl-C ends the command with nvx-rs's Interrupted and status 130, once. scripts/nvx_rs_fixtures.py recorded the golden fixtures of nvx-rs from the Python implementation of nvx.py sandbox, the egress compiler, and the Python semantics that the ports keep, and imported the helpers that this commit removes from nvx.py. With nvx.py running nvx-rs, the recordings are final, so the generator goes, and the fixtures stay as frozen references that a deliberate change updates by hand. Their metadata, .gitattributes, the README, the module comments that named the generator, and doc/project-structure.md say so. The CLI tests of the Python implementation go, since the golden scenarios of nvx-rs cover each of their cases, as do the systemd, environment, network-option, and policy checks, and the tests of `run` that also ran sandbox keep their `run` half. New tests check the recorded arguments for abbreviations, attached values, order, and the default operation, the call of nvx-rs and its status through main(), the usage errors that argparse keeps, a missing nvx-rs, and, through a fresh nvx.py with a stand-in for nvx-rs, shared standard streams and, on POSIX hosts, one Interrupted and status 130 after Ctrl-C to the process group. With NVX_RS set, the real nvx-rs runs a dry run of options that need an attached value, an abbreviation, and a share policy, a usage error with nvx.py's name, and a lifecycle error. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox will run nvx-rs sandbox with the options that its own argparse front end read, and its type conversions, such as --layer, --workload-user, and the integer options, move to nvx-rs. A value that nvx-rs then refuses would read "usage: nvx-rs sandbox ..." and "nvx-rs sandbox: error: ...", where nvx.py printed the usage and name of its own parser. A new global option, --prog PROG, names the program in usage, help, and error messages, nvx-rs by default; nvx.py will pass its own name. Each parser now describes its syntax as a Syntax value, its command after the program's name, the usage parts of its options and positionals, and the help after its usage, instead of static usage and help text, and Exit::Usage and Exit::Help carry the text that the syntax formats for the program. format_usage ports argparse's HelpFormatter._format_usage for 80 columns: a usage that fits stays on one line; otherwise a name of at most three quarters of the width is followed by the options, and the positionals start a new line, while a longer name takes a line of its own. For the default name, every usage and help is byte for byte what the static text printed, which the golden scenarios check, and for nvx.py, whose name has the length of nvx-rs, the usage wraps as Python's does. The top-level usage lists the new option, so the eight golden steps whose unrecognized arguments the top-level parser reports, and the generator's copy of that usage, change accordingly. The top-level help describes --prog and wraps the help of --root as argparse wraps it; it had one line of 79 columns. Unit tests compare the wrapping of short and long names, and of usages without options, with CPython 3.14's argparse, and a CLI test checks the usage error of nvx.py sandbox for a root --workload-user, recorded from CPython, a long name, the top-level parser, and help. The README documents the option. This changes the CLI of #596; it adds the option and keeps every default output. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 19, 2026
nvx.py sandbox now runs `nvx-rs --prog nvx.py --root REPO sandbox` for every operation, with nvx.py's standard streams, and exits with the status of nvx-rs, which ports command_sandbox, the lifecycle of sandbox_lifecycle.py, and the control session of control_session.py (#596). Python no longer checks, converts, or runs anything for the command; the modules that nvx-rs replaced stay for the microVM tests until a later commit moves those onto nvx-rs. argparse stays the front end, so that the help, abbreviations, and the usage errors that it reports (exit 2) work as before, and the help is unchanged byte for byte at 80 and 120 columns. Every option keeps its name, metavar, choices, and help, but none has a type converter or a default: --layer, --workload-user, the integer options, --timeout, and the paths are plain strings that nvx-rs converts and checks, and nvx-rs applies the defaults, so Python duplicates neither. A _SandboxOption action records each option as nvx-rs receives it: - under its canonical name, so that `--dry` reaches nvx-rs as --dry-run, since nvx-rs does not expand abbreviations; - in command-line order, so that each --mount-deny, --mount-allow, and --mount-write follows the --mount whose path it names, which nvx-rs attributes as nvx.py did; and - with its value attached, --option=VALUE, so that a value that starts with "-" stays a value. The operation, run by default, comes first. Only options that the user gave are passed, so that the defaults live in nvx-rs alone. --prog makes the usage errors that nvx-rs reports for the values that it converts read "usage: nvx.py sandbox ..." and "nvx.py sandbox: error: ...", as argparse printed them; the name has the length of nvx-rs, so the usage wraps the same. Usage errors keep their status and form, but which of two errors in one command line is reported changes: argparse reports an invalid choice or an unrecognized argument before nvx-rs sees a value that it refuses, where it used to report whichever came first. Ctrl-C ends the command with nvx-rs's Interrupted and status 130, once. scripts/nvx_rs_fixtures.py recorded the golden fixtures of nvx-rs from the Python implementation of nvx.py sandbox, the egress compiler, and the Python semantics that the ports keep, and imported the helpers that this commit removes from nvx.py. With nvx.py running nvx-rs, the recordings are final, so the generator goes, and the fixtures stay as frozen references that a deliberate change updates by hand. Their metadata, .gitattributes, the README, the module comments that named the generator, and doc/project-structure.md say so. The CLI tests of the Python implementation go, since the golden scenarios of nvx-rs cover each of their cases, as do the systemd, environment, network-option, and policy checks, and the tests of `run` that also ran sandbox keep their `run` half. New tests check the recorded arguments for abbreviations, attached values, order, and the default operation, the call of nvx-rs and its status through main(), the usage errors that argparse keeps, a missing nvx-rs, and, through a fresh nvx.py with a stand-in for nvx-rs, shared standard streams and, on POSIX hosts, one Interrupted and status 130 after Ctrl-C to the process group. With NVX_RS set, the real nvx-rs runs a dry run of options that need an attached value, an abbreviation, and a share policy, a usage error with nvx.py's name, and a lifecycle error. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Part of #359 (PR 3 of 5).
Summary
Adds the
nvx-rscommand-line interface: aclifeature, which implieshost, and annvx-rsbinary (required-features = ["cli"]). It ports the runtime steps thatnvx.pywill delegate to it in PR D, faithfully: the same options, wire formats, limits, messages, outputs, exit statuses, outcome reports, locks, and state directories.nvx-rs artifactsprints the artifact paths of Port the sandbox launch profile and artifact lookup to nvx-rs #595'sArtifactDir::paths()as JSON.nvx-rs egress compile PATHprints the native rules of Port egress policies to nvx-rs #594's policy compiler as JSON.nvx-rs sandbox [run|provision|start|exec|stop|deprovision]portscommand_sandboxofscripts/nvx.py,scripts/nvx_tools/sandbox_lifecycle.py, and the observable behavior ofcontrol_session.py, on Port the sandbox launch profile and artifact lookup to nvx-rs #595'snvx::host::sandboxand Port egress policies to nvx-rs #594's compiler, and reusesnvx::host::session,process, andplatform.startnow negotiates the guest's control features with a layered-sandbox required set (the nvx-rs: make the nvx-rs CLI the backend of nvx.py sandbox, egress, and artifact lookup #359 decision); see FEATURES.nvx.py(424 steps over both host kinds) are replayed bynvx-rs/tests/cli.rswithout Python, and the generator also runs every lifecycle withnvx.pyandnvx-rstaking turns both ways.CLI reference
Build:
cd nvx-rs && cargo build --release --features cli(target/release/nvx-rs[.exe]).Artifact root
--root PATH: an NVX repository checkout (build/,openvmm/target/release/) or an extracted release (guest/,bin/), told apart asArtifacts::from_dirdoes. PR D passes its repository root.--root: the release whosebin/holds the executable (<root>/bin/nvx-rs[.exe]), or the checkout whosenvx-rs/target/holds it at any depth, each with itsSOURCE-MANIFEST.json; otherwiseerror: cannot locate the NVX artifacts: pass --root ..., status 1.NVX_OPENVMM: an OpenVMM executable to run instead of the root's, made absolute against the working directory. It must be valid Unicode.artifactsPrints
json.dumps(value, indent=2, sort_keys=True)of:{ "guests": [ {"distribution": "Alpine Linux", "distro_layer": null, "distro_manifest": null, "initramfs": "ROOT/build/initramfs.cpio.gz", "name": "alpine", "package_manifest": "ROOT/build/initramfs.cpio.gz.packages.json"}, {"distribution": "Ubuntu Base", "distro_layer": "ROOT/build/ubuntu-distro.erofs", "distro_manifest": "ROOT/build/ubuntu-distro.erofs.manifest.json", "...": "..."}, {"distribution": "Azure Linux", "name": "azurelinux", "...": "..."} ], "kernel": "ROOT/build/vmlinux", "layout": "repository", "openvmm": "ROOT/openvmm/target/release/openvmm", "root": "ROOT" }layoutisrepositoryorrelease; every path is absolute and unchecked;openvmmisNVX_OPENVMMwhen set; guests are innvx.py's order (alpine, ubuntu, azurelinux).egress compile PATHPrints
{"allow": [RULE, ...], "deny": [RULE, ...]}in the same JSON layout, each list in the order in which OpenVMM receives--network-egress-allowand--network-egress-deny. A refused file printserror: <compile_policy_file's message>and exits 1.sandboxEvery option of the
sandboxsubparser ofscripts/nvx.py, with the same names, defaults, types, and choices (nvx-rs sandbox -hprints argparse's help withnvx-rs sandboxas the program).--opt valueand--opt=value; a value that starts with-needs=unless it looks like a negative number or holds a space; repeated options accumulate;--mount-deny,--mount-allow, and--mount-writeattach to the--mountbefore them; the operation defaults torun.run>> COMMAND(subprocess.list2cmdlineon Windows,shlex.joinelsewhere), then OpenVMM's own streams--dry-runprovisionstartexecstopdeprovision--outcome-report(run, exec): run passes--microvm-reportto OpenVMM; exec checks the destination before it runs and writes{"operation_id": HEX32, "outcome": {"category": ..., "operation": "exec", "status_code": ...}, "schema_version": 1}.State directory:
config.json(formats 1 to 6, the lowest that holds the sandbox),runtime.json(format 1:pid,start_time,control_endpoint),control.capability,control.sock(POSIX),openvmm.log,outcome.json, andlifecycle.lock(deprovisioned\ntombstone), all written as Python writes them; JSON isjson.dumps(value, indent=2, sort_keys=True) + "\n"byte for byte, mode 0600, through a temporary file and a rename.Exit statuses
error: MESSAGEon stderr: whatnvx.py'smain()prints for aScriptError,OSError,RuntimeError, orValueErrorPROG: error: MESSAGE, as argparse; unrecognized arguments are reported by the top-level parserInterruptedrun) or the workload's (exec), asSystemExit(status)Text ends lines as Python's
printdoes:\r\non Windows,\nelsewhere; workload output passes through unchanged.Test hooks (for PR D)
--root PATHandNVX_OPENVMM=PATH.nvx-fake-openvmm(--features testing) now serves the sandbox profile:--microvm-lifecycle managed(capability on stdin, control console,SANDBOX_AGENTfeatures, outcome report at stop) andone-shot(runs/bin/echo,/bin/sleep,/bin/true,/bin/false; others "not found" with 127; writes the outcome report if asked). Kernel command-line knobs:fake_exit_on_start=STATUS,fake_exit_status=STATUS,fake_guest_features=MASK,fake_legacy_guest=1,fake_boot_delay_ms,fake_crash_after_ms,fake_ignore_stop=1.NVX_OPENVMM=target/debug/nvx-fake-openvmm target/debug/nvx-rs --root .. sandbox run --layer distro,LAYER,UUID --scratch SCRATCH --entrypoint /bin/echo --arg hello.Stack
Based on #595 (
agents/fix-issue-359-launch-profile, 1fa32b8), which is based on #594 (agents/fix-issue-359-egress-policy, 78e77c3) and #561 (agents/fix-issue-358-build-switch, f1b8428). Until those merge, this PR's diff includes their commits. This PR's own commits:Each commit builds and passes clippy and the tests on Windows and Linux. Commit 1's lifecycle operations fail after their option checks until commit 2.
Changes to code that #594 and #595 own, kept additive: the private
pythonmodule gainsdumps,shell,value,float(), andlstat/lexists/fstat(cfgcli);scripts/nvx_rs_fixtures.pygains a CLI section and requires CPython 3.14.5+ for it. Base code gainsControlSession::send_request/read_response,process::Launchedwaits and kills, Windows byte locks and console interrupts, a Unix SIGINT handler, andGuestFeatures::LAYERED_SANDBOX.aci_edge_sandboxesandnvx::host::instanceare unchanged.Faithful-port notes
FEATURES
As decided in #359,
startsends FEATURES after its PING and requiresGuestFeatures::LAYERED_SANDBOX, notREQUIRED(the direct agent's set):EXEC_ENVIRONMENT: required, sinceexecsends a working directory and an environment in an extended EXEC.CANCEL: not required, deliberately. The CLI never sendsAPP_CANCEL: as withnvx.py, Ctrl-C ends anexecby closing the session, and the guest agent ends the workload when OpenVMM resets that session.WORKLOAD_ACCOUNT: never required (microVM: create requested workload accounts in the layered sandbox root, or stop advertising them #566).A guest that lacks a required feature, or predates the request (an unsupported-operation refusal counts as no features), is stopped and its runtime files removed as for a failed start, and start fails with
error: the guest image lacks control features that layered sandboxes need (per-execution environments); use an NVX release or build that includes them, naming the missing features withGuestFeatures::missing. The Rust agent of #561 and the C agent before it advertise bits 0, 2, and 4 in sandbox mode, and the real start on all three hypervisors negotiated successfully. This is the one intended behavior change;nvx.pydoes not ask.Other intended deviations
nvx.pywould end with a traceback (for example aconfig.jsonwithout a field that_startreads, or anOverflowError),nvx-rsprintserror: <str(exception)>, status 1.PYTHONIOENCODING/UTF-8 mode is set.starttreats a control endpoint served by a process other than the launched OpenVMM as not yet available (asnvx::host::sessiondoes), and starts OpenVMM without a console window on Windows (asnvx::host::processdoes).execrefuses more than the 1 MiB of workload output that the guest agent forwards (only a misbehaving guest sends more).nvx.py, a start whose OpenVMM exits at once waits until--timeoutfor the endpoint before reporting the exit; that is kept, not fixed.Port details worth reviewing
flock(LOCK_EX|LOCK_NB); WindowsCreateFileWwith read/write/delete sharing andFILE_FLAG_OPEN_REPARSE_POINT, thenLockFileof byte 0 asmsvcrt.locking(LK_NBLCK, 1). Standalone-regular-file checks, 25 ms retries within--timeout, hand-off waits, and the directory-removal protocol are ported, with Windows errors classed as CPython maps them./proc/PID/statstart time with zombies as exited; Windows creation time), so runtime records are valid across implementations.control_session.py's messages, per-operation deadlines, direction-specific timeout messages,ControlEndpointClosedversus reset, closing after a failed transfer, and exec's validation order. Waits run in 250 ms slices so Ctrl-C ends them.start: 32-byte non-zero capability on stdin through a pipe closed before OpenVMM starts;//./pipe/openvmm-microvm-<uuid4 hex>on Windows,<state>/control.sockelsewhere;_start's command line and network-option order; detached (own session /CREATE_NEW_PROCESS_GROUP), log appended; failed-start cleanup order and_startup_exit_message.run: OpenVMM gets 0.25 s on POSIX, then is killed, assubprocess.rundoes; on Windows the CLI waits, as Python's wait cannot be interrupted either. A recorded Ctrl-C decides how a command ends, asKeyboardInterruptdoes, also when the same Ctrl-C ended OpenVMM first.test_control_session.pymappinghost/cli/session/tests.rsunless noted)test_exec_streams_output_and_returns_bounded_statusexec_streams_output_and_returns_bounded_statustest_exec_rejects_unbounded_or_relative_argumentsexec_rejects_unbounded_or_relative_argumentstest_exec_encodes_extended_cwd_and_environment_exactlyexec_encodes_extended_cwd_and_environment_exactlytest_exec_distinguishes_omitted_and_empty_environmentexec_distinguishes_omitted_and_empty_environmenttest_exec_layers_only_a_supplied_environment_over_the_defaultsexec_layers_only_a_supplied_environment_over_the_defaultstest_exec_accepts_full_uint32_timeout_range_without_waitingexec_accepts_full_uint32_timeout_range_without_waitingtest_exec_rejects_invalid_timeout_and_execution_fields_before_sendingexec_rejects_invalid_timeout_and_execution_fields_before_sending(theTypeErrorcases have no CLI equivalent: values arrive parsed)test_exec_rejects_invalid_response_timeout_before_sendingexec_rejects_invalid_response_timeout_before_sendingtest_exec_rejects_unknown_exit_categoryexec_rejects_unknown_exit_category(alsocancelledand non-ASCII)test_exec_refusal_keeps_status_category_and_diagnosticexec_refusal_keeps_status_category_and_diagnostic(nvx.pyprints only the message)test_session_reset_is_not_a_closed_endpointsession_reset_is_not_a_closed_endpointtest_operations_bound_their_requests_by_their_deadlinesoperations_bound_their_requests_by_their_deadlinestest_failed_transfer_closes_the_sessionfailed_transfer_closes_the_sessionCapabilityPipeTests(3)nvx::host::process::spawn's (#561), which closes it before OpenVMM starts; every golden lifecycle start exercises it, since the fake reads exactly 32 bytes then EOF and refuses an all-zero capabilitytest_write_that_sends_nothing_reports_a_closed_endpoint(mock)a_closed_endpoint_ends_the_operation_with_its_own_errortest_read_reports_a_closed_endpointendpoints::read_reports_a_closed_endpoint(POSIX)test_reset_and_broken_pipe_report_a_closed_endpointendpoints::reset_and_broken_pipe_report_a_closed_endpoint(POSIX)test_request_to_a_stalled_endpoint_times_out(socket)request_to_a_stalled_endpoint_times_out_and_closes_the_sessiontest_write_that_moves_nothing_reports_a_closed_endpoint(mock)endpoints::write_reports_a_closed_endpoint(Windows)test_write_reports_a_closed_endpointendpoints::write_reports_a_closed_endpoint(Windows)test_read_reports_a_closed_endpoint_after_its_dataendpoints::read_reports_a_closed_endpoint_after_its_data(Windows)test_read_times_out_without_losing_later_datanvx::host::session::tests::timed_out_polls_keep_partial_records(buffering) andendpoints::read_times_out_and_closes_the_session(Windows)test_interrupted_write_is_cancelled_before_the_interruption_propagatesnvx::host::platformcancels a pipe write that times outtest_request_to_a_stalled_endpoint_times_out_and_closes_the_session(pipe)request_to_a_stalled_endpoint_times_out_and_closes_the_sessionAdded:
response_that_does_not_arrive_times_out,output_beyond_the_guest_limit_breaks_the_protocol,features_of_a_guest_that_predates_them_are_none,protocol_violations_keep_the_messages_of_python, andendpoints::endpoints_that_never_appear_time_out_with_their_pathon both hosts.Golden scenarios and interoperability
scripts/nvx_rs_fixtures.py(single--check) recordsnvx-rs/tests/fixtures/cli.jsonfromnvx.py sandboxrun as a command (subprocess,artifact_pathandopenvmm_binary_pathpatched to a temporary root whose OpenVMM is a copy ofnvx-fake-openvmm) and from the Python functions behindegress compileandartifacts: 24 usage errors and helps, 69–70command_sandboxand launch checks, 10–11 dry runs, 11 one-shot runs, lifecycles in every config format (format 3 on POSIX only, since Windows refuses caller-owned shares), and 8 refusal/failure scenarios; 215 steps on POSIX and 209 on Windows. Each step records status, stdout, stderr, and state files (config.jsonformats 1–6,runtime.json, entries and modes, outcome reports, and the command linestartpassed to OpenVMM), with the root, PIDs, start times, and random identifiers as placeholders. Theposixandwindowssections are recorded on each host kind. The generator then plays every scenario withnvx-rs, and every lifecycle withnvx.pyandnvx-rsalternating both ways (Python provisions and Rust starts, executes, stops, and deprovisions, and the reverse), and fails on any difference.nvx-rs/tests/cli.rsreplays the host's section without Python, plus the FEATURES rejection, root discovery, relativeNVX_OPENVMM, top-level usage errors, and Linux Ctrl-C duringrunandexec.Validation
nvx.py verify; nvx-rs fmt, Clippy (--all-targets --all-features,--lib --no-default-features), tests (--all-featuresand default), rustdoc-D warnings, MSRV 1.89,aarch64-apple-darwincheck, bundled-artifacts test; aci_edge_sandboxes fmt, Clippy ×2, tests ×2, doc, MSRV;test-agent-bundling --toolchain 1.95.0; ruff check/format; pyright Linux and Windows; markdown links; validate-nvx unittest list;nvx_rs_fixtures.py --check(CPython 3.14.7): all passtest-agent-bundling, validate-nvx unittests,nvx_rs_fixtures.py --check(CPython 3.14.7): all pass; every commit of this PR checked with fmt, Clippy ×2, andtest --all-featuresnvx-rs --features clion this branch, OpenVMM 5ab8a84 and Alpine initramfs from #594's worktree, Ubuntu distro layer and scratch template from #561's: one-shotrun(+--dry-run,--outcome-report,/bin/false→ 1);provision,start(FEATURES negotiated),execwith--cwd,--environment,--environment-file,--inherit-default-environment,--outcome-report,--exec-timeout-ms(124, timeout), missing program and cwd (125);stop,startagain,exec,stop,deprovision; anvx.py-provisioned sandbox started and exec'd bynvx-rs, exec'd and stopped bynvx.py, deprovisioned bynvx-rs: all as expected--hypervisor mshv: all as expected--hypervisor whp(named-pipe endpoint): all as expectedLeft to CI: the workload jobs (
test-microvm,test-openvmm, guest builds and determinism, benchmarks), which keep exercising the Python path until PR D; the guest-binary steps ofcheck-nvx-rs; and CI's own runs of the jobs above.